跳转至


课程  因子投资  机器学习  Python  Poetry  ppw  tools  programming  Numpy  Pandas  pandas  算法  hdbscan  聚类  选股  Algo  minimum  numpy  algo  FFT  模式识别  配对交易  GBDT  LightGBM  XGBoost  statistics  CDF  KS-Test  monte-carlo  VaR  回测  过拟合  algorithms  machine learning  strategy  python  sklearn  pdf  概率  数学  面试题  量化交易  策略分类  风险管理  Info  interview  career  xgboost  PCA  wavelet  时序事件归因  SHAP  Figures  Behavioral Economics  graduate  arma  garch  人物  职场  Quantopian  figure  Banz  story  量化传奇  rsi  zigzag  穹顶压力  因子  pe  ORB  策略  Xgboost  Alligator  Indicator  factor  alpha101  alpha  技术指标  wave  quant  algorithm  pearson  spearman  tushare  因子分析  Alphalens  涨停板  herd-behaviour  因子策略  momentum  因子评估  review  SMC  聪明钱  trade  history  indicators  zscore  波动率  强化学习  顶背离  freshman  resources  others  AI  DeepSeek  network  量子计算  金融交易  IBM  weekly  LLT  backtest  backtrader  研报  papers  UBL  quantlib  jupyter-notebook  scikit-learn  pypinyin  qmt  xtquant  blog  static-site  duckdb  工具  colors  free resources  barra  world quant  Alpha  openbb  数据  risk-management  llm  prompt  CANSLIM  Augment  arsenal  copilot  vscode  code  量化数据存储  hdf5  h5py  cursor  augment  trae  Jupyter  jupysql  pyarrow  parquet  数据源  quantstats  实盘  clickhouse  notebook  redis  remote-agent  AI-tools  Moonshot  回测,研报,tushare 

tools »

10 - Numpy应用案例[3]


“去极值是量化分析预处理中不可或缺的一步。在各种方法中,中位数拉回法因其鲁棒性和适应性广泛应用。通过 Numpy 的向量化实现,我们可以轻松完成多资产的去极值操作,显著提升计算效率。”


向量化又一例: 多资产中位数去极值

去极值是量化分析预处理中的常见步骤,在机器学习中也很常见。在各种去极值方法中,中位数拉回是对数据分布特性适应性最广、最鲁棒的一种。

我们先介绍绝对中位差(median absolute deviation)的概念:

\[MAD = median(|X_i - median(X)|)\]

为了能将 MAD 当成与标准差\(\sigma\)相一致的估计量,即 \(\(\hat{\sigma} = k. MAD\)\)

这里 k 为比例因子常量,如果分布是正态分布,可以计算出: $$ k = \frac{1}{(\Phi^{-1}(\frac{3}{4}))} \approx 1.4826 $$

基于这个 k 值,取 3 倍则近似于 5。

代码实现如下:

1
2
3
4
5
6
7
from numpy.typing import ArrayLike

def mad_clip(arr: ArrayLike, k: int = 3):
    med = np.median(arr)
    mad = np.median(np.abs(arr - med))

    return np.clip(arr, med - k * mad, med + k * mad)

1
2
3
np.random.seed(78)
arr = np.append(np.random.randint(1, 4, 20), [15, -10])
mad_clip(arr, 3)

这段代码只能对单一资产进行mad_clip。如果要同时对A股所有资产的某种指标去极值,上述方法需要循环5000多次,显然速度较慢。此时,我们可以使用下面的方法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
def mad_clip(df: Union[NDArray, pd.DataFrame], k: int = 3, axis=1):
    """使用 MAD 3 倍截断法去极值"""

    med = np.median(df, axis=axis).reshape(df.shape[0], -1)
    mad = np.median(np.abs(df - med), axis=axis)

    magic = 1.4826
    offset = k * magic * mad
    med = med.flatten()
    return np.clip(df.T, med - offset, med + offset).T

这一版的 mad_clip 可以接受 numpy ndarray 和 pandas dataframe 作为参数。输入的数据格式是什么,它返回的数据格式就是什么。

我们在np.median调用中,传入了 axis参数。如果axis=0, 表明按列的方向遍历,因此是按行取中位数;axis=1,表明按行的方向遍历,因此是按列取中位数。

我们使用真实数据测试一下:


1
2
3
4
5
6
7
# 加载测试数据
start = datetime.date(2023, 1, 1)
end = datetime.date(2023, 12, 29)
barss = load_bars(start, end, 7)

closes = barss["close"].unstack("asset").iloc[-5:]
closes

输出数据为:

asset/date 002095.XSHE 003042.XSHE 300099.XSHE 301060.XSHE 601689.XSHG 603255.XSHG 688669.XSHG
2023-12-25 23.400000 18.090000 6.10 13.00 73.910004 36.799999 18.080000
2023-12-26 21.059999 17.520000 5.94 12.83 72.879997 37.000000 18.080000
2023-12-27 20.070000 17.590000 6.04 12.84 72.000000 36.840000 18.049999
2023-12-28 20.010000 18.139999 6.11 13.14 72.199997 38.150002 18.440001
2023-12-29 20.270000 18.580000 6.19 13.29 73.500000 37.299999 18.740000

为了测试效果,我们将k设置为较小的值,以观察其效果:

1
mad_clip(closes,k=1)
asset/date 002095.XSHE 003042.XSHE 300099.XSHE 301060.XSHE 601689.XSHG 603255.XSHG 688669.XSHG
2023-12-25 23.400000 18.090000 10.217396 13.00 25.962605 25.962605 18.080000
2023-12-26 21.059999 17.520000 10.296350 12.83 25.863649 25.863649 18.080000
2023-12-27 20.070000 17.590000 10.325655 12.84 25.774343 25.774343 18.049999
2023-12-28 20.010000 18.139999 10.582220 13.14 26.297781 26.297781 18.440001
2023-12-29 20.270000 18.580000 10.659830 13.29 26.820169 26.820169 18.740000

我们看到,原始数据中的73.9被拉回到25.9,6.1被拉回到10.2(以第一行为例),并且都是以行为单位计算的。


min_range: 多少周期以来的最小值?

这是一个很常见的需求,比如,有股谚语云,天量见天价,地量见地价。当行情处在高位,成交量创出一段时间以来的天量之后,后续成交量将难以为继,容易引起下跌;当行情处在低位,成交量创出一段时间以来的地量之后,表明市场人气极度低迷,此时价格容易被操纵,从而引来投机盘。

在通达信公式中有此函数,在麦语言中,对应的方法可能是LOWRANGE。以下是myTT中LowRange函数的实现:

1
2
3
4
5
def LOWRANGE(S):                       
    # LOWRANGE(LOW)表示当前最低价是近多少周期内最低价的最小值 by jqz1226
    rt = np.zeros(len(S))
    for i in range(1,len(S)):  rt[i] = np.argmin(np.flipud(S[:i]>S[i]))
    return rt.astype('int')

这是一个看似简单,但实际上比较难实现的功能。如果我们对上述函数进行测试,会发现它不一定实现了需求(也可能是本文作者对此函数理解有误)。

1
2
3
s = [ 1, 2, 2, 1, 3, 0]

LOWRANGE(np.array(s))

在上述测试中,我们希望得到的输出是[1, 1, 1, 3, 1, 6],但LOWRANG将给出以下输出:


1
array([0, 0, 0, 2, 0, 0])

下面,我们给出该函数的向量化实现。

Warning

该函数在开头的几个输出中,存在出错可能。不影响因子分析,暂未修复。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
def min_range(s):
    """计算序列s中,元素i是此前多少个周期以来的最小值

    此方法在个别数字上有bug

    Example:
        >>> s = np.array([5, 7, 7, 6, 5, 8, 2])
        >>> min_range(s)
        array([1, 2, 1, 2, 3, 1, 6])
    """
    n = len(s)

    # handle nan
    filled = np.where(np.isnan(s), -np.inf, s)
    diff = filled[:,None] - filled
    mask = np.triu(np.ones((n, n), dtype=bool), k=1)
    masked = np.ma.array(diff, mask=mask)

    rng = np.arange(n)
    ret = rng - np.argmax(np.ma.where(masked > 0, rng, -1), axis=1)
    ret[0] = 1
    if filled[1] <= filled[0]:
        ret[1] = 2
    return ret

1
2
s = np.array([5, 7, 7, 6, 5, 8, 2])
min_range(s)

最终输出的结果是:

1
array([1, 1, 2, 3, 4, 1, 6])

在第2个7的位置,输出与期望不一致,但此后计算都正确。这个实现非常有技巧,运用了三角矩阵做mask array,从而消解了循环。

均线计算:SMA和分时均线

使用numpy计算移动均线非常简单,使用np.convolve()即可。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
def moving_average(ts: ArrayLike, win: int, padding=True)->np.ndarray:
    kernel = np.ones(win) / win

    arr = np.convolve(ts, kernel, 'valid')
    if padding:
        return np.insert(arr, 0, [np.nan] * (win - 1))
    else:
        return arr

moving_average(np.arange(5), 3)

输出结果为array([nan, nan, 1., 2., 3.])


移动均线是只考虑价格信息的一种均线。分时均价线则则同时纳入成交量和成交价信息的均线,在日内交易中有特别重要的含义。比如,在市场不好的情况下,如果个股价格位于分时均线下方,此前两次上冲均线失败,那么,一旦冲第三次失败,一般认为要尽快卖出。反之亦然。

均价线的计算如下:

如果当前时刻为t,则用开盘以来,直到时刻t为止的成交金额除以成交量,即得到该时刻的累积成交均价。将所有时刻的成交均价连接起来,即构成了分时均价线。

这个功能看似复杂,但由于numpy提供了cumsum函数,因此实际上计算非常简单:

1
2
3
4
5
def intraday_moving_average(bars: DataFrame)->np.ndarray:
    acc_vol = bars["volume"].cumsum()
    acc_money = barss["amount"].cumsum()

    return acc_money / acc_vol

在本环境中,只提供了日线数据,我们以日线代替分钟线进行测试:

1
2
3
4
5
start = datetime.date(2023, 1, 1)
end = datetime.date(2023, 12, 29)
barss = load_bars(start, end, 1)

intraday_moving_average(barss)

计算最大回撤

最大回撤(MDD)是指投资组合从最高点到最低点的最大观察损失,直到达到新的最高点。最大回撤是一定时间周期内的下行风险指标。

\[ MDD = \frac{Trough Value - Peak Value}{Peak Value} \]

max drawdown是衡量投资策略风险的重要指标,因此,在empyrical库中有实现。不过,作为策略风险评估指标,empyrical没必要返回duration等信息,也没有实现滑动窗口下的mdd。现在,我们就来实现滑动版本。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# https://stackoverflow.com/a/21059308
from numpy.lib.stride_tricks import as_strided
import matplotlib.pyplot as plt

def windowed_view(x, window_size):
    """Creat a 2d windowed view of a 1d array.

    `x` must be a 1d numpy array.

    `numpy.lib.stride_tricks.as_strided` is used to create the view.
    The data is not copied.
    Example:

    >>> x = np.array([1, 2, 3, 4, 5, 6])
    >>> windowed_view(x, 3)
    """

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
    """
    array([[1, 2, 3],
           [2, 3, 4],
           [3, 4, 5],
           [4, 5, 6]])
    """
    y = as_strided(x, shape=(x.size - window_size + 1, window_size),
                   strides=(x.strides[0], x.strides[0]))
    return y

def rolling_max_dd(x, window_size, min_periods=1):
    """Compute the rolling maximum drawdown of `x`.

    `x` must be a 1d numpy array.
    `min_periods` should satisfy `1 <= min_periods <= window_size`.

    Returns an 1d array with length `len(x) - min_periods + 1`.
    """
    if min_periods < window_size:
        pad = np.empty(window_size - min_periods)
        pad.fill(x[0])
        x = np.concatenate((pad, x))
    y = windowed_view(x, window_size)
    running_max_y = np.maximum.accumulate(y, axis=1)
    dd = y - running_max_y
    return dd.min(axis=1)

np.random.seed(0)
n = 100
s = np.random.randn(n).cumsum()
win = 20
mdd = rolling_max_dd(s, win, min_periods=1)

plt.plot(s, 'b')
plt.plot(mdd, 'g.')
plt.show()

测试表明,当时序s长度为1000时,rolling_max_dd的计算耗时为100𝜇S。

滑动窗口下,生成的mdd与原序列对照图如下:

该方法中,还简单地封装了一个将一维数组转换为滑动窗口视图的函数,可以在其它地方使用。


寻找自适应参数

很多基于技术指标的交易策略往往指定了固定的阈值。比如,一些人会在RSI 80以上做空,在RSI 20以下做多。即使是用在指数和行业板块上,这样的指标仍然不够精确,因为在上行通道中,RSI的顶点会高于下行通道中的RSI顶点;在下行通道中,RSI的底部则会比上行通道中的RSI底部低很多。

此外,不同的标的,RSI取值范围也不一样。不仅仅是RSI,许多技术指标都存在需要根据当前的市场环境和标的,采用自适应参数的情况。

其中一个方案是使用类似于布林带的方案,使用指标均值的标准差上下界。但这个方案隐含了技术指标均值的数据分布服从正态分布的条件。

我们可以放宽这个条件,改用分位数,即numpy的percentile来确定参数阈值。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
%precision 2

from IPython.core.interactiveshell import InteractiveShell
InteractiveShell.ast_node_interactivity = "all"

np.random.seed(78)
s = np.random.randn(100)

hbound = np.percentile(s, 95)
lbound = np.percentile(s, 5)

1
2
s[s> hbound]
s[s< lbound]

通过percentile找出来超过上下界的数据,输出如下:

1
2
array([2.09, 2.27, 2.21, 2.12, 2.19])
array([-1.68, -2.4 , -1.97, -1.7 , -1.46])

一旦指标超过95%的分位数(hbound),我们就做空;一旦指标低于5%的分位数(lbound),我们就做多。

这里我们也可以使用中位数极值法。一旦指标超过中位数MAD值的3倍,就发出交易信号 。